/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package com.xpn.xwiki.web;

import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

import javax.inject.Named;
import javax.inject.Singleton;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xwiki.component.annotation.Component;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.XWikiException;
import com.xpn.xwiki.web.sx.AbstractSxAction;
import com.xpn.xwiki.web.sx.Extension;
import com.xpn.xwiki.web.sx.JsExtension;
import com.xpn.xwiki.web.sx.JsExtension.JsCompressor;
import com.xpn.xwiki.web.sx.SxCompressor;
import com.xpn.xwiki.web.sx.SxSource;

/**
 * <p>
 * Action for serving javascript skin extensions.
 * </p>
 * 
 * @version $Id$
 * @since 1.4M2
 */
@Component
@Named("jsx")
@Singleton
public class JsxAction extends AbstractSxAction
{
    /** The extension type of this action. */
    public static final JsExtension JSX = new JsExtension();

    /** Logging helper. */
    private static final Logger LOGGER = LoggerFactory.getLogger(JsxAction.class);

    private static final String SOURCE_MAPS_SESSION_ATTRIBUTE = JsxAction.class.getName() + ".sourceMaps";

    /**
     * We need to add minify=false so that we don't try to minify the source map.
     */
    private static final String SOURCE_MAP_PARAMS = "sourceMap=true&minify=false";

    @Override
    public Extension getExtensionType()
    {
        return JSX;
    }

    @Override
    protected Logger getLogger()
    {
        return LOGGER;
    }

    @Override
    public void renderExtension(SxSource sxSource, Extension sxType, XWikiContext context) throws XWikiException
    {
        if (isSourceMapRequest(context)) {
            // See if there is a saved source map that matches the current URL and return it.
            super.renderExtension(new SxSource()
            {
                @Override
                public long getLastModifiedDate()
                {
                    return sxSource.getLastModifiedDate();
                }

                @Override
                public String getContent()
                {
                    return loadSourceMap(context);
                }

                @Override
                public CachePolicy getCachePolicy()
                {
                    return sxSource.getCachePolicy();
                }
            }, new JsExtension()
            {
                @Override
                public String getContentType()
                {
                    return "application/json";
                }
            }, context);
        } else {
            // Compress the source code and generate the source map.
            super.renderExtension(sxSource, sxType, context);
        }
    }

    @Override
    protected String compress(String source, SxCompressor compressor, XWikiContext context)
    {
        String output = super.compress(source, compressor, context);

        // Save the source map generated by the compressor so that we can return it later when the source map is
        // requested by the browser's developer tools.
        if (compressor instanceof JsCompressor) {
            String sourceMap = ((JsCompressor) compressor).getSourceMap();
            if (sourceMap != null) {
                // The browser's developer tools will attempt to load the source code when debugging the compressed
                // code. The source code URL is specified in the source map.
                sourceMap = fixSourceURL(sourceMap, context);
                // Indicate the URL to the source map using the dedicated HTTP header. This is how the browser's
                // developer tools will know how to download the source map.
                // See https://developer.mozilla.org/en-US/docs/Tools/Debugger/How_to/Use_a_source_map
                context.getResponse().setHeader("X-SourceMap", saveSourceMap(sourceMap, context));
            }
        }

        return output;
    }

    /**
     * Saves the given source map in the current HTTP session and returns the URL that can be used to access it.
     * 
     * @param sourceMap the source map to save
     * @param context the XWiki context used to access the HTTP session
     * @return the URL that can be used to access the source map
     */
    private String saveSourceMap(String sourceMap, XWikiContext context)
    {
        @SuppressWarnings("unchecked")
        Map<String, String> sourceMaps =
            (Map<String, String>) context.getRequest().getSession().getAttribute(SOURCE_MAPS_SESSION_ATTRIBUTE);
        if (sourceMaps == null) {
            sourceMaps = new HashMap<>();
            context.getRequest().getSession().setAttribute(SOURCE_MAPS_SESSION_ATTRIBUTE, sourceMaps);
        }

        String currentURL = context.getURL().toString();
        sourceMaps.put(currentURL, sourceMap);

        // Compute the source map URL by adding the source map parameters to the query string of the current URL.
        return extendQueryString(currentURL, SOURCE_MAP_PARAMS);
    }

    /**
     * @param context the XWiki context
     * @return the source map that corresponds to the current HTTP request
     */
    private String loadSourceMap(XWikiContext context)
    {
        @SuppressWarnings("unchecked")
        Map<String, String> sourceMaps =
            (Map<String, String>) context.getRequest().getSession().getAttribute(SOURCE_MAPS_SESSION_ATTRIBUTE);
        if (sourceMaps != null) {
            String sourceMapKey = context.getURL().toString();
            // Remove the source map parameters from the query string.
            sourceMapKey = sourceMapKey.replaceFirst("(\\?)" + SOURCE_MAP_PARAMS + "(&|$)", "$1");
            sourceMapKey = StringUtils.removeEnd(sourceMapKey, "?");
            return sourceMaps.getOrDefault(sourceMapKey, "");
        }
        return "";
    }

    private String extendQueryString(String url, String params)
    {
        // We add the new parameters at the start of the query string to be sure they are not overwritten by the rest of
        // the query string.
        int insertionPoint = url.indexOf('?') + 1;
        if (insertionPoint <= 0) {
            return url + '?' + params;
        } else {
            return url.substring(0, insertionPoint) + params + '&' + url.substring(insertionPoint);
        }
    }

    private String fixSourceURL(String sourceMapJSON, XWikiContext context)
    {
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            ObjectNode sourceMap = (ObjectNode) objectMapper.readTree(sourceMapJSON);
            ArrayNode sources = (ArrayNode) sourceMap.get("sources");
            // Closure compiler can add synthetic sources when polyfills are embedded in the compilation so we need to
            // modify the last source in the array.
            if (!sources.isEmpty()) {
                sources.set(sources.size() - 1, sources.textNode(getSourceURL(context)));
            }
            return objectMapper.writeValueAsString(sourceMap);
        } catch (Exception e) {
            LOGGER.warn("Failed to fix the source path in the generated JavaScript source mapping. Root cause is [{}].",
                ExceptionUtils.getRootCauseMessage(e));
            return sourceMapJSON;
        }
    }

    private String getSourceURL(XWikiContext context)
    {
        XWikiURLFactory urlFactory = context.getURLFactory();
        String sourceURL = extendQueryString(urlFactory.getRequestURL(context).toString(), "minify=false");
        try {
            // Try to return a relative source URL because this is going to be saved in the source map.
            return urlFactory.getURL(new URL(sourceURL), context);
        } catch (MalformedURLException e) {
            LOGGER.warn("Failed to convert absolute source URL to relative URL. Root cause is [{}].",
                ExceptionUtils.getRootCauseMessage(e));
            return sourceURL;
        }
    }

    private boolean isSourceMapRequest(XWikiContext context)
    {
        return "true".equals(context.getRequest().getParameter("sourceMap"));
    }
}
